Adobe “杀死了” Flash,我尝试在游戏上“复活了”它!
Flash 虽已被时代淘汰,但是也有不少开发者不愿放弃。
原文链接:https://foon.uk/how-flash-2022/
声明:本文为 CSDN 翻译,未经允许,禁止转载。
2020 年 Adobe 结束了 Flash Player 的生命,但我不想让我的 Flash 游戏就此消失。
我一直在断断续续地开发游戏,最受欢迎的是一款名为 Hapland 的游戏,所以我觉得把它移植到 Steam 上应该会不错。我可以绘制更好的图像,改进刷新率和分辨率,并添加一些额外的隐藏任务。
Hapland 2
但问题是,这款游戏本身是 Flash 游戏。图像是用 Flash 画的,代码是用 Flash 编写的,所有动画都是用 Flash 的时间线动画制作的。可以说整个游戏完全建立在 Flash 之上。
该怎么办呢?
失败的尝试
首先,我试着用 Flash 导出游戏,生成可执行文件。如果成功的话,本文到这里就可以结束了,但很不幸的是我并没有成功,因为导出后游戏的性能一夜回到 2005 年。我想按照现代的帧速率来运行,同时也希望摆脱 Flash 播放器。
其次,我花了很多时间研究 Adobe AIR。这是 Flash 和 Starling 的桌面运行时,后者是在 GPU 上绘制 Flash 场景的库。
最后我放弃了,部分原因是 AIR 有很多 bug,而且很难用,但主要原因是我不想用一个奇怪的 Adobe 软件作为最终解决方案,我希望能完全用我自己的技术来代替。考虑到以后可能会移植到 Linux,我不想受 Adobe 的限制。
所以目标就很明确了:我需要编写自己的 Flash 播放器。
计划
下面,我简要介绍一下这款游戏的构建。所有精灵以树形结构组织。在 Flash 中,动画精灵可以在某些帧上添加代码,当播放到该帧时就会运行这段代码。这款游戏使用了许多这种代码。游戏角色的行走路线只不过是时间线很长的动画而已,角色上经常会有帧动作,比如当到达门口时,如果门是关着的,就把门打开,或者当遇到地雷时,如果地雷还没有爆炸,就引爆。
时间线上的“a”标记表示帧动作
幸运的是,.fla文件只不过是XML而已。我需要解析该文件,将相关的数据导出到一个自定义的格式中,然后编写播放器来读取、描绘场景、处理输入,然后运行动画。我还需要处理ActionScript。
最终这依然是一个Flash项目,用Flash编辑器编写和维护,只是更换了Flash播放器而已。
向量图栅格化
Flash中的图形都是向量图。虽然 Flash 支持位图,但本身是为向量图设计的。因此Flash动画在当年的拨号连接上都能快速加载。这款游戏的所有图形都是向量图。
但是GPU不喜欢向量图。它们擅长处理大量的带有材质的三角形。所以,我需要将向量图栅格化。
我决定进行离线栅格化,然后将栅格化后的文件打包到游戏中。在运行时进行栅格化应该很有意思,而且能保持小体积的可执行文件,但我不想在游戏中加入额外的处理。我希望尽可能多地让代码在我自己的机器上运行,这样可以保证它们不出问题。
Flash的向量图保存在XML文件中。你也许会说,XML并不适合保存图形数据,但毕竟这是Macromedia的产品设计师决定的。
.fla文件中保存的向量数据
我并不是在抱怨,相反,我的工作因此更轻松了一些。
即使我没有具体的规格,对其进行栅格化也并不困难。向量图的贝赛尔曲线模型从PostScript诞生以来就没有变过。所有API的工作方式也和当年一样。经过一番尝试后,我弄清楚了 ! 和 [ 等符号的意义,于是写了一个程序来解析这些形状定义,并利用Mac的CoreGraphics将其渲染成PNG图像。
选择CoreGraphics让我颇为犹豫了一番。我选择它主要是因为我在Mac上开发,而Mac恰好提供了这个库,我又不想麻烦使用其他的依赖。但这个选择导致只能在Mac上进行栅格化,即使是构建Windows版也不得不这样做。如果可以重来一次,我会选择一个跨平台的库。
在渲染好PNG之后,导出程序将这些PNG图像拼成一张精灵图。方法很简单,只是将所有图像按照尺寸排序,然后逐行排列在一起而已。这远远不是最佳方案,但已经足够好了。
为了保持简单,精灵图的尺寸为2048x2048像素,是OpenGL 3.2支持的最小材质尺寸。
游戏中的一张精灵图
光栅化非常慢,所以为了保证构建时间不会太长,我需要跳过没有变化的部分。Flash使用的压缩后的XML文件中有一个字段表示最终变更时间,但Flash似乎并未正确使用该字段,所以无法依靠它。
于是我计算了每个图形的XML的哈希值,仅在哈希值发生变化的时候进行构建。即使是这样,有时也会出问题,因为Flash有时候会重新排列XML标签,即使图像没有任何变化。但同样,这样做已经足够好了。
使用组装程序写入二进制文件
导出程序将动画数据写入到一个自定义的二进制格式中。该程序只是逐帧遍历时间线,然后将每帧的变化都写出来。
这里,我想出的一个方法是将数据导出成汇编代码,而不是二进制文件。当然汇编代码并不是CPU指令,只是数据而已。这样调试可以更容易一些,因为我可以翻阅汇编文件,查看生成的内容,而不需要通过二进制编辑器来查看每个字节。
哪个更容易调试?
我可以选择让导出程序将字节码写到一个文件中,同时将文本代码写到另一个文件中,但我并没有这样做,而是选择了汇编器,因为(1)我已经有汇编器了;(2)不需要再调试汇编器;(3)汇编器支持标签。
导出程序的其余部分就没什么意思了,只不过是遍历整个树,然后转换变换矩阵、颜色效果等数据。最后再进一步转换游戏程序本身。我选择C++编写导出程序的原因只是我熟悉C++而已。
场景图
这款游戏非常适合使用场景图。场景图是Flash采用的模型,游戏就围绕着场景图设计,所以没有道理换成其他模型。
我将场景作为树的节点保存在内存中,每个节点有一个变换,可以绘制自己,并接受鼠标点击事件。每个游戏对象都拥有自己的行为,是自身的类的一个实例,这些类是从Node类继承而来的。虽然在游戏设计圈,面向对象已经不流行,但因为我用的是Flash,所以并不在乎。
游戏使用的Flash特性(如颜色变换、遮罩等)都是现成的,尽管我实现的遮罩并不支持任意形状的遮罩,而只是矩形裁切而已,然后我将所有图形都编辑成了矩形,这样所有遮罩都用矩形就可以了。
帧脚本
这款游戏几乎所有的逻辑都是用ActionScript编写的附着在时间线上的帧脚本。这些要怎么导出?我不想在游戏中包含ActionScript解释器。
一个简单的帧动作
最终,我采用了一个小技巧。导出程序会读取每一帧的ActionScript,然后用一堆正则表达式将其替换成C++。例如,crate.lid.play() 会转换成 crate()->lid()->play(); 。这两种语言在语法上非常相似,所以对于大部分简单的帧脚本来说,这种方式的效果很好,但依然会产生不少错误的代码,这些就只能手工重写了。
将所有的帧脚本转换成C++之后,就可以在编译时将其提取出来,变成每个符号的Node子类中的方法。同时还会生成一个负责分发的方法,负责在正确的时机调用这些方法。这个分发方法大致如下:
void tick() override {
switch (currentFrame) {
case 1: _frame_1(); break;
case 45: _frame_45(); break;
case 200: _frame_200(); break;
}
}
我想说明的最后一点是,最后生成的脚本是静态类型的,这一点很好,因为ActionScript本身并没有类型。而导出程序生成的游戏对象大致如下:
struct BigCrate: Node {
BigCrateLid *lid() { return (BigCrateLid *)getChild("lid"); }
BigCrateLabel *label() { return (BigCrateLabel *)getChild("label"); }
void swingOpen() { ... }
void snapShut() { ... }
void burnAway() { ... }
};
所以,虽然底层依然是一大堆字符串查找,但这一层类型安全能够防止在错误的对象上调用错误的方法,避免了一大堆在动态类型语言中由于输入错误而导致的烦人的bug。
长宽比
有过将旧媒体文件转成新格式经验的人对此应该不陌生。原来的游戏在浏览器上运行,根本没有考虑到全屏运行,所以长宽比是随意选取的。每个游戏都不一样,但大致都在3:2左右。
现在最常见的长宽比是16:9,一些笔记本上也有16:10的长宽比。我希望游戏能在这两种长宽比上正常运行,不会出现黑条,也不会拉伸图像。唯一的做法就是将原图切掉一部分,或加上一部分。
所以,我在每个游戏上画了两个矩形,一个是16:9,一个是16:10。然后游戏会根据屏幕分辨率计算矩形大小,然后用计算出的矩形作为相机的视图边界。只要所有重要的游戏元素都在这两个矩形的相交部分,且相交部分的边界不会超出场景边缘,就没问题。
16:10和16:9的矩形框。原始比例为3:2
唯一的难题是让场景本身适应额外的宽度,这需要重新绘制许多图形并重新排列,以适应新的长宽比。尽管有一点痛苦,但最后还是搞定了。
痛苦的色彩空间
经过一番测试后,我发现Flash的阿尔法混合和颜色变换不是在线性空间内进行,而是在感知空间内进行的。从数学角度而言这样做是否正确还有待商榷,但我能理解为什么,因为许多绘画程序都是这样做的,它们希望能按照人们期待的方式工作,而不会考虑那些不懂商业的数学家的想法。但我还是要说,这样做是错误的!这样会导致抗锯齿等功能出现问题。
在对向量图进行光栅化时,你需要产生抗锯齿的输出,此时光栅化程序会产生一些阿尔法值,叫做“覆盖值”,意思是,如果某个像素在向量图中被盖住了一半,那么该像素的alpha值就是0.5。
但在Flash中,alpha等于0.5的意思是说它的颜色介于前景色和背景色之间的一半。
这两者完全不一样!
在不透明的黑色像素上绘制一个半覆盖的白色像素,其结果不应该是50%的灰色。光的原理并非如此,向量图的栅格化也不是这样工作的。(没有背景色,栅格化过程做不到“该像素的颜色应该位于前景色和背景色之间的 x%”。)
图:感知空间(sRGB)内的颜色混合。上:黑色上方的透明白色;中:白色上方的透明黑色;下:灰色
图:线性空间中的同样的混合(物理上准确的结果)。注意50%覆盖率看上去与50%灰并不一样。
现在,经过抗锯齿栅格化后的图形使用的是阿尔法混合模式,而Flash导出的阿尔法透明度、渐变和颜色变换使用的是另一种混合模式。但是渲染流水线中只有一个阿尔法通道。那么,渲染器应该怎样解释阿尔法值呢?如果按照感知混合的方式解释,那么半透明的物体是正确的,但抗锯齿边缘和其他效果就是错误的。如果按照覆盖值来解释,那么结果正相反。总会有一些是错误的!
我只想到了两个方法来解决这个问题:1) 设置两个阿尔法通道,一个保存覆盖值,一个用于感知混合值;2) 在栅格化图形时不加抗锯齿,而是将图形绘制在一个非常大的帧缓冲区中,然后利用过滤算法将其缩小。
但最后我没有采用任何一种方法,我接受了半透明效果在Flash和游戏中不同这一事实,然后不断调整图形直到在游戏中的效果满意。透明物体在Flash中永远不会和实际效果一致,但幸好透明物体不是太多,所以不是太大的问题。
为了确保其他效果是正确的,我做了一个“颜色测试”图,其中包含了多种浓度的多个颜色,以及色相切换效果等,然后在游戏中显示,确保它在游戏中和Flash中显示效果一样。
图:显示效果是一致的!
帧速率
原来的Flash游戏的速率为24fps,但实际上,其帧速率取决于Flash播放器的心情。在Flash中,24fps的实际速率可能只有15fps,30fps的实际速率是24fps……非常不靠谱。
我希望重制后能达到60fps,这意味着我需要对动画做一些处理,因为游戏的动画是按照24fps设计的。(Flash的动画工具只能在离散帧上创作,不能处理连续的时间。)
首先,我尝试让导出器将帧数加倍。也就是说,将时间线上的每一帧导出成两帧。这样很容易就能获得48fps,但距离60fps还有一定距离,所以动画速度还是快了25%。最后的解决方案很朴实——我玩了一遍游戏,然后把动画过快的地方手动加上几帧。
现在,我有了一个很不错的C++版本的游戏,能在现代电脑上至少再稳定运行一二十年。不过我觉得还能再加一些额外的功能,所以除了重画许多图形、改进动画之外,我还做了下面这些改进。
保存游戏状态
在制作这款游戏时,我想减小玩家的压力,所以想出了这个想法。整个游戏的流程很长,而且有许多很容易失败而不得不重来的地方。也许在2006年这不算什么,但现在我们都长大了,没有时间玩如此硬核的游戏。
许多模拟器都有保存游戏状态的功能。按下“保存游戏状态”后,模拟器会将整个模拟游戏机的内存保存到文件中。这样,如果失败,只要按下“加载游戏状态”,就可以从保存的地方重新开始。
在原始的Flash游戏中没办法实现该功能,因为Flash没有给开发者提供任何访问整个游戏状态的接口。但现在我的游戏引擎是自己写的,所以可以实现了。
我将这个功能称为Zone,它只是一个分配器,将内存按照固定大小进行分配。场景中的所有节点都分配到当前的Zone中。
要实现保存和加载,只需要实现两个Zone,一个是活跃的Zone,另一个是“保存状态”的zone。要保存状态时,只需将活跃zone memcpy到保存状态zone中。加载状态时按照相反方向memcpy即可。
副任务
这款游戏本身的流程不算长,有三个任务,不过我还是想让人们再多玩几个小时。于是我给每个游戏增加了一个“副任务”——一个游戏的修改版本,其布局和谜题略有不同。制作这种副任务的工作量比制作新游戏小得多,但能达到不错的效果。
制作副任务意味着我要回去修改15年前做的Flash游戏,不过我还挺享受这一过程的。
Flash的界面很不错。按钮有边缘,图标是写实风格的,空间利用率也非常棒。使用旧的界面感觉就像是考古学家在探索被遗忘的罗马科技一样。失落的界面设计。
这些是什么魔法?
而且,虽然Flash有很多bug,还很慢,缺乏非常基本的功能,但我并不讨厌使用它。我也没找到什么更好用的现代程序。
为了避免副任务与主任务雷同,我为它们画了新的背景,整个场景也水平翻转了一下。
Hapland 3
Hapland 3 副任务
音乐
我给每个游戏都添加了一个背景音乐,用的是我自己录制的一些音乐和现成的音效。有一次我去日本旅游,突发奇想去了某座山上的录音棚里录了一些东西。我在网上找了个音乐人帮我录了片头音乐,然后自己给片尾录了一些吉他和弦,还加了一些特效,免得让别人听出我的吉他水平很差。
我会根据情况使用Logic或Live编辑音乐。我觉得Logic更适合录音,而Live更适合设计音效。
成就
玩家们都很喜欢Steam游戏中的成就系统。虽然成就系统给游戏设计师加重了负担,但也不算太麻烦。
将成就上传到Steam非常麻烦。你没办法定义一个成就列表,然后用命令行工具上传,只能通过又慢又破的PHP网站一个个上传。
我觉得Steam可能会为大型游戏工作室提供一个批量导入工具,但我没有这种工具,所以我分析了HTTP请求,保存下cookie然后自己写了一个导入工具。
在反复推敲了几遍之后,我找到了一套还算不错的成就方案:完成每个游戏获得一个成就,完成每个副任务获得一个成就,然后几个重要的隐藏要素都有成就。正常玩家找不到的奇怪的隐藏要素没有设置成就,玩家只能获得发现的快感。
Steamworks的成就上传界面
应用公证
虽然我主要在Mac上开发游戏,但苹果的“公证”机制非常令人头疼。每当运行MacOS应用时,苹果都会检查开发者是否缴纳了年费。如果开发者没有付年费,MacOS就会强烈地暗示该应用是个病毒,并且拒绝启动。
出于这个原因,Windows将是我的首选平台,而且以后可能只发布Windows版。
使用的库
发布给最终用户的软件我会尽可能减少依赖,但我也不介意使用一些高品质的库。除了OpenGL和一些操作系统标准库之外,整个游戏系列采用了下面几个库:
Steam SDK
cute_sound
stb_vorbis
stb_image
结束语
整个过程很有意思。只要技术实现得正确,玩家几乎注意不到任何区别。
如果想支持我,可以在Steam上购买Hapland Trilogy,或我在2020年后做的另一个游戏 Blackshift。我在foon.uk上也有一些基于浏览器的免费游戏。比较新的游戏是用JavaScript或WASM写的,而较旧的游戏(包括原版的Hapland)是AS2 Flash,由于有Ruffle支持,运行得还不错。后面的AS3的游戏无法运行了。
《2022-2023 中国开发者大调查》重磅启动,欢迎扫描下方二维码,参与问卷调研,更有 iPad 等精美大礼等你拿!